Skip to content

feat: #75 PR 3 — auto-Task wrap 분기 + session/a2a_context 종료 모델 정정 (docs first)#79

Merged
hagyutae merged 13 commits into
mainfrom
feat/75-pr3-task-wrap-session-cleanup
May 8, 2026
Merged

feat: #75 PR 3 — auto-Task wrap 분기 + session/a2a_context 종료 모델 정정 (docs first)#79
hagyutae merged 13 commits into
mainfrom
feat/75-pr3-task-wrap-session-cleanup

Conversation

@hagyutae
Copy link
Copy Markdown
Contributor

@hagyutae hagyutae commented May 8, 2026

Summary

#75PR 3. 세 가지 작업 묶음:

  1. auto-Task wrap 분기 — callee graph 의 LLM structured output 으로 requires_task 결정 → handler 가 hint 만 보고 Message / Task 분기
  2. session 종료 개념 제거 — sessions 는 영구 namespace, ended_at / SessionEndEvent / SessionEndProcessor 폐기
  3. agent graph building blocks 추출 — Primary / Librarian 의 99% 동일 graph.py 코드 정리. shared 가 ReAct building blocks 만 제공, 각 agent 가 명시적 조립 (옵션 D)

부수: 옛 디자인 (workflow.base + extensions / langgraph_base / extensions/ 디렉터리) 와 어긋난 docs 일괄 정정.

(1) auto-Task wrap 분기 — LLM 추론 기반

결정 메커니즘

callee graph 안 classify_response 노드가 llm.with_structured_output(A2AResponseDecision) 으로 LLM 강제 — 자기 응답 의도 (조회 / 위임 / long-running) 보고 requires_task 채움. handler 는 graph state 에서 hint 만 읽어 분기 (분류 / 분석 로직 0).

class A2AResponseDecision(BaseModel):
    requires_task: bool        # LLM 추론 결정
    reason: str                # 결정 근거 (관찰용)

분기 결과

결정 publish 패턴 DB
requires_task=False a2a.context.start + a2a.message.append × 2 a2a_messages=2, a2a_tasks=0
requires_task=True 위 + a2a.task.create + a2a.task.status_update × 2 (WORKING/COMPLETED) + agent reply with task_id a2a_messages=2, a2a_tasks=1, a2a_task_status_updates=2

Streaming 한계

SendStreamingMessage 는 SSE 형식 자체가 Task 중심 (initial Task → TaskArtifactUpdateEvent × N → TaskStatusUpdateEvent) 이라 항상 Task wrap. classify_response 는 실행되며 hint 는 graph state 에 기록되지만 (관찰용) handler 가 사용하지 않음. Message-only streaming 은 향후 확장.

Persona 가이드

Primary persona text 에 결정 가이드 단편 추가 (위임 / long-running = true, 조회 / fact = false).

Prompt 위치 — shared

A2A protocol-level 결정이라 prompt 텍스트는 shared/a2a/decision.py:DEFAULT_RESPONSE_DECISION_PROMPT 상수. 모든 agent 가 import. 코드 안 자연어 자료 박지 않는다는 root CLAUDE.md "AI 에이전트 런타임 자산" 원칙 준수.

(2) session 종료 모델 정정

session 은 사용자 대화창 metaphor 상 종료 개념 없음 (Slack DM / ChatGPT 류 — 사용자가 언제든 재개).

폐기

  • SessionEndEvent 클래스 + wire session.end
  • SessionEndProcessor (chronicler)
  • sessions.ended_at 컬럼 (migration 005)
  • SessionUpdate.ended_at / SessionRead.ended_at 필드
  • (이전 PR 2 에서 이미) UG publish_session_end 호출

유지

  • A2AContextEndEvent / A2AContextEndProcessor / a2a_contexts.ended_at — agent 가 결정해 발화 (PR 5 예정)

(3) agent graph building blocks 추출 (옵션 D)

Primary / Librarian 의 graph.py 가 99% 동일 (persona / tools 만 다름) 인 상태에서 새 agent 추가마다 복붙 강요되던 구조 정리. shared 가 building blocks 만 제공하고 각 agent 가 자기 graph 를 명시적으로 조립 — topology 자유 보존 + DRY.

신설 — shared/src/dev_team_shared/agent_graph/

ReAct 패턴 building blocks (Pattern B — in-process library):

  • make_llm_call_node(persona, llm_with_tools) — persona + messages → LLM
  • make_tool_node(tools) — tool_calls 실행 → ToolMessage
  • should_continue_react(state, *, when_done) — tool_calls 분기 helper
  • serialize_tool_result(value) — tool 결과 → JSON

확장 — shared/src/dev_team_shared/a2a/decision.py

A2A 프로토콜 차원 결정 (모든 agent 공유):

  • A2AResponseDecision Pydantic schema
  • DEFAULT_RESPONSE_DECISION_PROMPT (system prompt)
  • make_classify_response_node(llm, *, system_prompt) — LangGraph 노드 factory
  • format_conversation_for_classifier(messages) — Anthropic "must end with user msg" 회피용 helper

분류 원칙

  • ReAct 일반 building blocks → agent_graph/
  • A2A 프로토콜 결정 → a2a/decision.py
  • agent 정체성 / 도메인 워크플로 → 각 agent 의 config/ (persona) + resources/*.md

결과

각 agent graph.py 는 building blocks 조립만:

  • Primary: 217 → 107 줄 (50% 감소)
  • Librarian: 187 → 84 줄 (55% 감소)
  • Librarian 도 자동으로 classify_response 포함 (protocol-level 일관)

LangGraph subgraph feature 는 사용 가능

위 결정은 config 가 dispatch 하는 패턴 (workflow.base + extensions) 거부일 뿐, LangGraph 자체의 subgraph (StateGraphStateGraph) composition 은 자유롭게 사용 가능. 향후 Architect 의 three-stage design 같이 자연스러운 subgraph 단위는 build_graph() 안에서 명시적으로 끼움 — config 가 아닌 코드가 결정.

변경 파일

코드 — A2A response decision

  • shared/src/dev_team_shared/a2a/decision.py (신설) — schema + prompt + factory + helper
  • shared/src/dev_team_shared/a2a/__init__.py — export
  • agents/primary/config/base.yaml — persona 결정 가이드

코드 — handler 분기

  • shared/src/dev_team_shared/a2a/server/graph_handlers/send_message.py — hint 보고 Message / Task 분기
  • shared/src/dev_team_shared/a2a/server/graph_handlers/factories.py — trivial Message helper 신설
  • shared/src/dev_team_shared/a2a/server/graph_handlers/stream.py — classify_response token 필터

코드 — agent graph building blocks

  • shared/src/dev_team_shared/agent_graph/__init__.py (신설)
  • shared/src/dev_team_shared/agent_graph/react.py (신설) — ReAct building blocks
  • agents/primary/src/primary_agent/graph.py — shared blocks 조립으로 슬림화
  • agents/librarian/src/librarian_agent/graph.py — 동일 패턴 + classify_response 자동 포함

코드 — session 종료 cleanup

  • shared/src/dev_team_shared/event_bus/events.pySessionEndEvent 폐기
  • shared/src/dev_team_shared/event_bus/__init__.py — exports 정리
  • shared/src/dev_team_shared/doc_store/schemas/session.pyended_at 필드 제거
  • chronicler/src/chronicler/processors/__init__.pySessionEndProcessor 등록 제거
  • chronicler/src/chronicler/processors/session_end.py — 삭제
  • mcp/doc-store/migrations/005_drop_session_ended_at.sql (+ rollback) — 컬럼 drop

문서

  • shared/CLAUDE.md — §3 카탈로그에 agent_graph/ 추가, a2a/decision.py 역할 명시
  • shared/src/dev_team_shared/a2a/messaging.md — trace 시작점 셋, context lifecycle, Task/Message 분기 (LLM 추론 + streaming 한계 + prompt 위치)
  • docs/proposal/architecture-chat-protocol.md — chat lifecycle 표 + tier 비교 표 (session.end 제거)
  • docs/proposal/architecture-event-pipeline.md — chat / a2a layer 이벤트 표 (session 종료 개념 없음 명시 + a2a.context.end agent 결정)
  • docs/proposal/architecture-user-gateway.md — UG publish 항목
  • docs/proposal/architecture-role-config.mdworkflow 필드 = 현재 미사용 명시
  • docs/proposal/architecture-agent-internals.md — building blocks 조립 패턴 + LangGraph subgraph 사용 가능성 명시
  • docs/proposal/knowledge-model.md — sessions / a2a_contexts schema 설명
  • docs/proposal/project-structure.md — 옛 구조 → 현 구조 전면 재작성

테스트

  • chronicler/tests/test_handler.py — SessionEnd 관련 제거 (13 → 12)
  • shared/tests/test_event_bus.py — chat layer 검증 갱신 (3 → 2 events)

검증

  • 단위: shared 57/57 · chronicler 12/12 · doc-store 24/24 pass
  • e2e (SendMessage 직접 — Primary):
    • 정보 조회 prompt → LLM requires_task=False → a2a_messages=2, a2a_tasks=0
    • 위임 시도 prompt → Primary 가 미구현 인식 → Message 응답 (정확) ✅
  • e2e (UG /api/chat = SendStreamingMessage):
    • 단순 인사 → LLM requires_task=False 기록 + 실제론 Task wrap (streaming 제약) ✅
  • e2e (Librarian): 부팅 정상, classify_response 자동 포함 확인
  • migration 005 적용 후 sessions.ended_at 컬럼 drop 확인

후속

  • PR 4 (확장 — PR 4 + 옛 PR 5 합침) — UG↔P/A chat protocol end-to-end 도입:
    • chat protocol wire (Primary chat endpoint REST POST + SSE)
    • UG 전환 (A2A → chat protocol)
    • FE multi-chat UI
    • Primary graph 의 chat session 통합 (session_id ↔ thread_id)
    • agent-side chat.append (role=agent) publish
    • Assignment 라이프사이클 publish (P 가 chat 중 합의된 work item)
    • A2AContextEndEvent 발화 위치 결정 (agent 가 inter-agent 대화 마무리 판단)
    • 작업 큼 — commit 분리로 리뷰. 머지 시점엔 chat protocol 이 wire + graph + UG + FE 모두 정합 단위
    • 자세한 인계: memory/pr4_chat_protocol_followups.md

🤖 Generated with Claude Code

hagyutae added 13 commits May 8, 2026 22:29
PR 1/2 후 재검토 결과 옛 모델의 두 가정이 잘못이었음을 정정.

session (사용자 대화창) — 종료 개념 없음:
- Slack DM / ChatGPT 류로 사용자가 언제든 재개
- session.end 이벤트 / sessions.ended_at 컬럼 / SessionEndProcessor 모두
  두지 않는다 — archive 가 필요해지면 별도 컬럼 (archived_at) 으로

a2a_context (에이전트 간 대화) — 종료는 agent 가 결정:
- agent 가 "이 inter-agent 대화 마무리" 라 판단한 시점에 a2a.context.end
  publish. RPC 라이프사이클이 아님 (한 contextId 위 다중 RPC 누적 가능)
- 발화 위치 / 결정 로직은 agent 통합 PR 에서

폐기:
- session.end → a2a_contexts cascade close 논의 (session 안 끝나니 cascade
  자체 없음)
- "trace 는 사용자 의도에서만 시작" 가정 (autonomous / system trigger 도
  trace 시작점)

영향 문서:
- architecture-chat-protocol.md: chat lifecycle 표 + tier 비교 표
- architecture-event-pipeline.md: chat layer / a2a layer 이벤트 표
- architecture-user-gateway.md: UG publish 항목
- knowledge-model.md: sessions / a2a_contexts schema 설명
 PR 3)

trace 정의 보완:
- 시작점 셋 — 사용자 / agent autonomous / 외부 system trigger
- 사용자 의도에서만 시작한다는 옛 가정 폐기. agent 가 자력으로 시작한 일도
  trace 가 붙어 boundary 가로지르는 추적 가능

Context lifecycle (start / end) section 신설:
- start: 새 contextId 첫 RPC 시 (CHR idempotent dedup)
- end: agent 가 "대화 마무리" 판단 시. RPC 단위 아님
- session 과 다르게 a2a_context 는 종료 개념 있음 (agent 가 관리하는 대화)

Task wrap vs Message-only 분기 신설 (#75 PR 3):
- spec 가이드 "Messages for Trivial, Tasks for Stateful" 따라 method-level
  분기 — SendMessage = Message, SendStreamingMessage = Task wrap
- server / graph 안 LLM 분류 도입하지 않음 (호출자가 method 선택으로 명시)
- 같은 contextId 위에 두 method 가 섞여도 같은 a2a_context 에 누적
  (contextId 가 grouping key, method 무관)

publish 패턴은 별도 publish.py 섹션에서 구현 시 매핑.
이전 commit 의 §3.4 가 두 axis 를 합쳐버림 — method (transport) 와 response
shape (trivial / stateful) 는 spec 상 직교. 정정:

- method 는 transport (sync vs streaming) 만 결정
- response shape (Message only / Task wrap) 는 agent (graph) 결정
- 4가지 조합 모두 가능 — 예: SendStreamingMessage 도 trivial 이면 Message stream

결정 메커니즘:
- graph state 에 hint 명시 (`requires_task: bool` 또는 `task_state`)
- handler 는 hint 만 보고 wrap 분기 — 분류 로직 0
- graph 가 hint 미구현이면 default 는 Message only (Task 는 명시적 opt-in)

publish 패턴도 method 가 아닌 결정 결과 (Message only / Task wrap) 별로 정리.
룰베이스 / 휴리스틱 옵션 제거. callee graph 안 LLM 이 자기 응답이 trivial
인지 stateful 인지 추론해 결정. "그게 진짜 에이전트" — 룰 따르지 않고 추론.

구현:
- ReAct 응답 generation LLM 의 structured output 에 `requires_task` 필드
  포함 (schema 강제)
- LLM 이 자기 응답 의도 (조회 vs 위임 / fact 확인 vs long-running) 를
  보고 직접 채움
- handler 는 hint 보고 wrap 분기 — 분류 / 분석 로직 0
- persona text 에 결정 가이드 단편 추가

default (hint 누락 시) 는 Message only — Task 는 LLM 명시 시에만.
callee agent 의 graph 안 LLM 이 응답이 trivial / stateful 인지 추론한 결과를
담는 Pydantic schema. graph 측은 `llm.with_structured_output(A2AResponseDecision)`
으로 schema 강제. handler 측은 graph state 의 hint 만 읽어 wrap 분기.

Fields:
- requires_task: True 면 Task wrap (a2a.task.create + status_update 발화)
- reason: LLM 의 결정 근거 (관찰 / 디버깅용)

shared/a2a/__init__ 에 export 추가.
Primary graph 의 `classify_response` 노드가 LLM structured output 으로
`requires_task` 결정 → handler 가 hint 만 보고 Message / Task 분기.

agents/primary:
- graph.State 에 `requires_task: NotRequired[bool]` 추가
- `_make_classify_response_node` — `llm.with_structured_output(A2AResponseDecision)` 로 LLM 강제
  · 대화를 user message text 로 직렬화 (Anthropic "must end with user msg" 제약 회피)
  · 실패 시 보수적 default (Message only)
- ReAct edge: `llm_call` 종료 → `classify_response` → END
- persona text 에 결정 가이드 추가 (위임 / long-running = true, 조회 / fact = false)

shared/a2a/server/graph_handlers:
- send_message.py: graph.ainvoke 결과의 `requires_task` 보고 분기
  · True → 기존 Task wrap (publish task.create + WORKING + agent reply + COMPLETED)
  · False (default) → Message only (publish message.append × 2, task.* 0)
- factories.py: trivial 응답용 helper 신설 (`make_agent_reply_message` /
  `make_agent_error_message` — task_id 비움)
- stream.py: classify_response 노드의 token 은 SSE stream 에서 제외 (사용자
  미노출)

streaming 경로 (`SendStreamingMessage`) 는 SSE 형식이 Task 중심이라 항상
Task wrap 유지. classify_response 는 실행되어 hint 가 graph state 에 기록은
되지만 (관찰용) handler 가 사용 X. messaging.md §3.4 에 명시.

검증:
- shared 57/57 / chronicler 12/12 / doc-store 24/24 unit pass
- e2e (SendMessage 직접 호출):
  · 정보 조회 → LLM `requires_task=False` → a2a_messages=2, a2a_tasks=0 ✅
  · 위임 미구현 인식해 정보 응답 → 같은 결과 ✅
- e2e (UG /api/chat = SendStreamingMessage):
  · 단순 인사 → LLM `requires_task=False` 기록만, 실제론 Task wrap (streaming 제약) ✅
…Event / Processor 폐기

#75 PR 3: session 은 사용자 대화창 metaphor 상 종료 개념 없음 (Slack DM /
ChatGPT 류) — 사용자가 언제든 재개. archive 가 필요해지면 별 컬럼
(`archived_at`) 으로.

shared/event_bus:
- `SessionEndEvent` 클래스 삭제 + EventType / __all__ 정리
- 본문 주석에 폐기 사유 명시

shared/doc_store/schemas/session:
- SessionUpdate 의 `ended_at` 필드 제거 (metadata 만 남음)
- SessionRead 의 `ended_at` 필드 제거

mcp/doc-store/migrations:
- 005_drop_session_ended_at — `sessions.ended_at` 컬럼 drop

chronicler:
- `SessionEndProcessor` 파일 삭제 + ALL_PROCESSORS / __all__ 정리
- test_handler.py: SessionEnd 관련 import / 테스트 / count assertion 삭제
- _session_read helper 의 ended_at 인자 제거

shared/tests/test_event_bus.py:
- chat layer publish 테스트의 SessionEnd 발화 제거 (총 3 → 2 events)

`A2AContextEndEvent` / `A2AContextEndProcessor` / `a2a_contexts.ended_at` 은
유지 — agent 가 결정해 발화 (PR 5 예정).
messaging.md §3.4 에 현재 구현 상 SendStreamingMessage 는 항상 Task wrap
임을 명시. 이유: SSE 형식이 Task 중심 (initial Task → TaskArtifactUpdateEvent
× N → TaskStatusUpdateEvent). Message-only streaming 은 별도 SSE 흐름이
필요해 향후 확장.

classify_response 노드는 streaming 경로에서도 실행되며 hint 가 graph state
에 기록되지만 (관찰용) handler 가 사용 X. SendMessage (sync) 만 hint 따라
실제 분기.
graph.py 안에 박혀있던 `_CLASSIFY_PROMPT` 상수가 프로젝트의 prompt 자료
처리 패턴 위반 — root CLAUDE.md "AI 에이전트 런타임 자산" 원칙은 자연어
prompt 자료는 코드 밖 (config / resources) 으로 분리.

본 결정은 agent 정체성이 아닌 A2A 프로토콜 차원 — 모든 agent (Primary /
Architect / Engineer / QA) 가 동일 기준으로 답해야 함. 따라서 agent 별
resources 가 아닌 shared 로:

- shared/src/dev_team_shared/a2a/decision.py 에 `DEFAULT_RESPONSE_DECISION_PROMPT`
  상수 추가 + __init__ export
- agents/primary/.../graph.py 는 import 해 사용 — 코드 안 자연어 0
- shared/.../a2a/messaging.md §3.4 에 prompt 위치 / 원칙 명시 (재발 방지)

검증: shared 57 / chronicler 12 unit pass + e2e 동작 동일 (LLM
requires_task=False 결정 로그 확인).
#75 PR 3)

Primary 와 Librarian 의 graph.py 가 99% 동일 (persona / tools 만 다름) 한
상태에서 agent 추가마다 복붙 강요되던 구조를 정리. shared 가 building
blocks 만 제공하고 각 agent 는 자기 graph 를 명시적으로 조립 (옵션 D —
topology 자유 보존 + DRY).

신설:
- `shared/src/dev_team_shared/agent_graph/react.py` — ReAct 패턴 building blocks
  · `make_llm_call_node(persona, llm_with_tools)` — persona + messages → LLM
  · `make_tool_node(tools)` — tool_calls 실행 → ToolMessage
  · `should_continue_react(state, *, when_done)` — 분기 helper
  · `serialize_tool_result(value)` — tool 결과 → JSON

확장:
- `shared/src/dev_team_shared/a2a/decision.py` — `make_classify_response_node`
  factory 추가 (graph.py 안에 박혀 있던 것 이동) + `format_conversation_for_classifier`
  helper. A2A 프로토콜 차원 결정이라 모든 agent 공유.

분류 원칙:
- ReAct 일반 building blocks → `agent_graph/`
- A2A 프로토콜 결정 → `a2a/decision.py`
- agent 정체성 / 도메인 워크플로 → 각 agent 의 config / resources
…으로 graph.py 슬림화 (#75 PR 3)

각 agent 의 graph.py 가 ReAct building blocks 와 classify_response factory 를
shared 에서 import. 코드 ~80% 감소 + protocol-level 일관 동작 (모든 agent 가
classify_response 자동 포함).

agent 별 차별화 지점은 graph.py 의 build_graph() 안에서 명시적 조립 (어떤
노드 / edge 로 구성할지) — topology 자유 보존.

Primary:
- ReAct + classify_response (#75 PR 3 의 분기)
- tools 4 채널 (Doc Store / IssueTracker / Wiki / Librarian A2A)

Librarian:
- 동일 구조 (Primary 와 같은 패턴) + classify_response 자동 포함
- tools = Doc Store read 단일 채널

agent 정체성은 config/base.yaml (persona) / resources/*.md (도메인 가이드)
에 그대로 — graph.py 는 조립만.

검증: shared 57/57 unit pass + e2e 양쪽 정상:
- SendMessage 직접 (Primary) → LLM `requires_task=False` → a2a_tasks=0 ✅
- UG /api/chat streaming → 같은 LLM 결정, streaming 제약으로 Task wrap ✅
…ns 폐기 + 옛 구조 정정

본 PR 3 의 graph 리팩터링 (shared/agent_graph/ 신설 + workflow.extensions
패턴 채택 안 함) 을 반영해 docs 정정. 추가로 옛 구조 (langgraph_base /
adapters / broker / mcp-servers) 가 현 구현과 어긋난 부분도 정합.

shared/CLAUDE.md §3 카탈로그:
- `agent_graph/` 추가 (Pattern B — LangGraph building blocks)
- `a2a/decision.py` 의 역할 명시 (A2A 응답 shape 결정)

docs/proposal/architecture-role-config.md:
- `workflow` 필드 — "필수" → "현재 미사용". graph 토폴로지는 graph.py 가
  shared/agent_graph 조립으로 표현 (#75 PR 3 옵션 D 채택)
- 역할별 yaml 예시들 위에 정정 노트 추가 — `workflow.base + extensions`
  블록은 옛 디자인 의도 기록으로만 남김

docs/proposal/architecture-agent-internals.md:
- "공통 베이스 그래프 위에 sub-graph 모듈을 얹는 구조" 설명 정정 → building
  blocks 조립 패턴
- agent 별 능력 (user_chat / prd_authoring / three_stage_design 등) 은
  "추후 노드로 분화될 책임 영역" 으로 재정의 — 현재는 persona 가이드 +
  ReAct 루프로 흡수

docs/proposal/project-structure.md (전면 재작성):
- 옛 구조 (langgraph_base / extensions/ / adapters / broker / mcp-servers)
  → 현 구조 (agent_graph / shared 8 서브패키지 / mcp/ / chronicler 등)
- agent 별 차별화 지점 표 추가 (정체성 / 워크플로 / 도구 / 토폴로지 / 연결)
- Pattern A/B 분류 / Role Config 로딩 / Chronicler 정체성 등 현 사실 반영
직전 commit 의 "공통 베이스 그래프 위에 sub-graph 모듈을 얹는 방식은
채택하지 않음" 표현이 LangGraph subgraph feature 자체 거부로 읽힐 수 있어
정정. 실제로 거부한 건 두 개념 중 하나만:

거부: config-driven extension dispatch (workflow.base + workflow.extensions
로 config 가 dispatch → 코드가 dynamic compose)

허용: LangGraph 자체의 subgraph (StateGraph 안 StateGraph) composition.
graph.py 의 build_graph() 안에서 코드가 명시적으로 끼우는 형태. 예: Architect
의 three-stage design 같이 자연스러운 subgraph 단위는 자유롭게 사용.

차이는 "누가 dispatch 하는가" — config 가 아닌 코드. 옵션 D 정신 그대로.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant